查看原文
科技新闻

ReactNative文本截断问题解决与Android文本测绘原理分析

快手大前端技术 快手大前端技术 2023-09-27

小编导读


本文主要针对ReactNative中由来已久的文本截断问题,进行了深入分析与解决。同时,对Android系统文本测绘的整体架构和流程进行了较为全面而简要的介绍。希望对受文本截断问题困扰和对文本测绘原理感兴趣的读者有所帮助。

文 / 编辑 李志强

From 快手主站跨端动态化团队

本文共8000+字,预计阅读时间30+分钟。首次阅读建议不用关注细节,只关注结论相关部分即可。

#  背景介绍

ReactNative文本截断问题背景

#

文本测绘原理

整体架构

关键类分析

测绘流程

本章小结

#

文本截断问题分析与解决

fontFamily为null导致的文本截断问题

设置系统字体缩放导致的文本截断问题

文本截断问题线上监控方案

#

总结

全文总结


背景介绍

文本截断问题,也被称作手机吞字问题,具体表现为,当设置文本为“Hello React Native App”只能显示“Hello React Native”,或者设置文本为“100”只能显示“10”。该问题是一个机型相关的问题,主要存在部分厂商的部分机型上,如OPPO、小米以及Samsung等。

目前React Native社区存在大量文本截断的反馈且至今没有得到很好的解决,而这些反馈由于和机型以及ROM版本存在强相关,且不同case之间具体场景不尽相同,相关issue并未得到有效的统一处理。本文将针对这一棘手问题在Native侧进行分析与解决。

文本测绘,是一项历史久远、繁杂而且系统的技术,它在各种渲染系统中也是最基本、最重要的组成部分。因此,在正式分析ReactNative中文本截断问题之前,我们有必要了解一下文本测绘的底层设计与原理。

对ReactNative文本‍截断问题情况有兴趣的读者可以查阅相关issues:

https://github.com/facebook/react-native/issues?q=is%3Aissue+is%3Aopen+text+cut。


文本测绘原理


文本测绘,听起来大家可能都很陌生,但是其实这是一项起源很久远的技术。比如,字型 (Font)这个术语最早就是来源于印刷行业的铸造铅字模块,当时的字体排版师傅必须用这些字块在印刷机上进行排版。了解文本测绘相关知识,将有助于解决文本渲染中的疑难问题,实现复杂的文本渲染需求。

想要讲清楚文本测绘原理不是一件容易的事情,目前公开的资料中,基本上都是针对其中某个环节或者某个部分进行介绍,而没有相对系统的学习资料。我们在这里尝试弥补这一空白,希望帮助读者朋友对文本测绘原理整体面貌有一个大概的掌握,进而能够在解决具体的文本相关的需求和问题时,能够起到一定的指引作用。

当然,我们也参考了网上很多不错的文章,在文末参考资料部分将会一一列出。

整体架构


注:下图由于平台大小限制,省略了很多细节,需要高清大图的读者可以私信留言。

文本测绘的整体架构图如下所示,首次阅读可以不用关注细节,只需要大概知道有哪些类就可以了,特别是其中颜色加深的部分。我们接下来会就其中关键的类以及它们自己相互的关系进行具体的介绍。


文本测绘架构的关键主要有四个类:Spanned、TextPaint、Layout和TextLine,它们分别承担着数据抽象、能力实现、接口定义和逻辑控制的角色,了解了它们的实现和相互依赖关系,也就基本了解Android文本测绘原理的整体面貌。接下来,我们将对这四个关键类进行逐一介绍。


关键类分析


1. Spanned

"Span",中文意为“跨度”,可以理解为一段连续的区域或者片段。在文本测量中,我们认为Span表示文本串中一段连续的区间。

Spanned接口是Span在Android系统中的相关操作的抽象,换句话说,Spanned接口定义了一些方法用来操作特定的Span对象。以getSpans为例,其定义如下:

public <T> T[] getSpans(int start, int end, Class<T> type);它代表的操作为:获取区间[start, end]中类型为type的所有Span那么,在Android系统中Span具体是用什么来表示的呢?其实,Android系统中并没有特定的一个类或者接口来表示Span,而是由CharacterStyle抽象类和ParagraphStyle接口共同表示这一抽象概念,它们分别从字符级和段落级两个不同的粒度来影响文本的样式和布局。
也就是说,如果一个Span影响了字符级的文本样式和布局,那么它应该是CharacterStyle的实例,应该继承CharacterStyle;如果一个Span影响了段落级的文本样式和布局,那么它应该是ParagraphStyle的实例,应该实现ParagraphStyle接口。
除此之外,系统还专门从样式和布局维度抽象了UpdateAppearance接口和UpdateLayout接口,它们定义中没有包含任何内容,因此它们只是作为标记性接口使用。具体来说,
  • UpdateAppearance:是一个影响字符级的文本展示的Span的顶级接口,并且对应的Span在被添加或者移除的时候会改变文本的展示。
  • UpdateLayout:是一个影响字符级文本格式的Span的顶级接口,并且对应的Span会影响文本的布局。由于布局更新也会明显地影响文本展示,因此,该接口也是一个UpdateAppearance接口。

UpdateLayout有一个顶级实现类MetricAffectingSpan,而MetricAffectingSpan同时也是一个抽象类,并提供了一个抽象方法updateMeasureState()用来更新文本的宽、高等样式。

CharacterStyle从字符级设置文本相关布局和样式,其相关的类层级结构如下图所示:

CharacterStyle相关的类层级结构如下图所示:

其中,进行字符替换的Span都直接继承了ReplacementSpan,例如ImageSpan;会影响到文本测量布局的Span都直接继承了MetricAffectingSpan,例如AbsoluteSizeSpan、ScaleXSpan等;而那些只会影响文本字符的显示,不会影响测量布局的Span都只继承了CharacterStyle而非MetricAffectingSpan,例如BackgroundColorSpan、URLSpan等。

无论是MetricAffectingSpan还是CharacterStyle,从其抽象方法参数类型可以看出,它们设置Span相关信息的方式,都是通过设置TextPaint来实现的,具体的逻辑和过程,在介绍TextLine的时候我们会详细介绍。

ParagraphStyle则是从段落级设置文本相关的布局和样式,其相关的类层级结构如下图所示:

ParagraphStyle也只是一个标记性接口,不包含任何属性和方法,其子类则主要是影响了行高、段落边距、对齐方式、制表符、项目编号样式等等文本段落样式相关的属性。


2. TextPaint & FontMetrics

TextPaint继承自Paint类,其绝大部分功能都由父类Paint实现。Paint是一个和C++底层接口交互的Java类,它保存着测绘图形、文本和图片必要的信息。其类结构如下图所示。

本文主要介绍文本测绘相关的内容,因此,上图着重体现了文本测绘相关的内容,而Paint本身具有非常丰富的接口,支持着Android系统绝大多数的测绘能力。
Paint中直接保存了一部分的测绘属性,包括颜色、阴影、字体相关信息等等,还有一部分信息则是直接保存到C++对象mNativePaint中,如setTextSize()、setTextAlign()等等。从上图中可以看到TextPaint和父类Paint均提供了一个set()接口,而事实上这是一个拷贝函数,用于拷贝Paint实例的所有属性的。由于有一部分文本测绘相关属性保存在C++对象中,所以该接口的一部分实现是在C++侧完成的。
Paint提供了measureText()系列接口和getRunAdvance()系列接口,二者都可以用来测量文本的宽度,区别是后者会考虑文本串内部文字方向的变化,而前者不会。除此之外,Paint还提供了breakText()接口在指定maxWidth的条件下测量文本,返回被测量的字符个数以及相应的测量宽度measuredWidth。以上接口的最终实现均在系统底层C++侧实现。

FontMetrics是文本测绘中使用的字体度量相关的结构体,其定义如下:
public static class FontMetrics {  public float   top; public float ascent; public float descent;  public float   bottom; public float leading;}
其中各个字段对应的数据原点称为baseline,而baseline对应着文本渲染起始坐标(x, y)中的y值,所以上述结构中不包含baseline字段,其他各个字段的含义如下:
  • ascent:baseline之上到字符最高处的距离,符号为负;

  • descent:baseline之下到字符最低处的距离,符号为正;

  • top:给定字体大小的字体中字形最高处到baseline的值,即ascent绝对值的最大值,符号为负;

  • bottom:给定字体大小的字体中字形最低处到baseline的值,即descent的最大值,符号为正;

  • leading:两行文本之间的行距,即当前行字符descent到下一行的ascent之间的距离,符号非负。

上述各度量属性之间的关系示意图如下所示。

FontMetricsInt是FontMetrics的整数版本,而BoringLayout.FontMetrics是其子类,并且增加了一个width字段,用来表示字符串的测量宽度,而这个宽度值最终将会影响TextView组件的宽高布局


3. Layout

如果说Spanned相关对象是文本测绘过程中最底层的数据抽象,那么Layout则是该过程中最上层的逻辑抽象。Layout它定义了文本测绘过程中需要的所有顶层API,并在系统的Layout&Measure&Draw过程中由TextView组件触发调用,最终影响TextView组件的样式和布局。

从上面Layout的类结构图中可以看到,Layout作为一个文本测绘相关顶级抽象类,定义了基本的数据依赖和接口,而和每一行文本具体布局相关的接口均由子类来实现。

Layout包含了一个mSpannedText成员,用来表示所有处理的mText是否是Spanned的对象,而Layout默认只处理ParagraphStyle类型的Span对象。比如,在其draw()方法中会调用两个方法:drawBackground()和drawText(),其中

  • drawBackground()
会对每一行文本进行判断,如果当前行文本设置了LineBackgroundSpan对象,则会调用LineBackgroundSpan#drawBackground()方法绘制对应Span的背景样式。
  • drawText()

会对每一行文本进行三项处理。首先,如果存在LeadingMarginSpan或者LeadingMarginSpan2对象,则会调用LeadingMarginSpan#drawLeadingMargin()方法绘制对应Span前置段落边距。其次,根据AlignmentSpan计算文本对齐方式、调整缩进宽度等。最后,如果当前文本行是Spanned对象,则会调用TextLine#draw()方法进行绘制。普通文本则直接通过Canvas#drawText()绘制。

Layout对象还持有一个TextPaint对象mPaint,该对象保存着对文本的所有和字体相关的属性,包括fontFamily、fontWeight、fontSize等等。
Layout共有三个子类:BoringLayout、StaticLayout和DynamicLayout。
  • BoringLayout:用来处理只有一行,且文字方向为LTR(LeftToRight)的文本。

  • StaticLayout:用来处理不能编辑的文本。它会根据Spanned和指定的宽度计算文本行数,而文字的方向则由文本内容决定。

  • DynamicLayout:用来处理可编辑的文本,它除了具有StaticLayout的功能外,还支持在编辑的同时动态更新文本的布局。在实现上,DynamicLayout有一个ChangeWatcher对象mWatcher,这个对象观察者Spannable的变化。当Spannable更新的时候,Layout#getLines()也会随之变化。


4. TextLine

TextLine对象负责处理每一行带有特定样式的文本,是每一行文本测绘任务的具体执行者,在文本测绘中发挥着至关重要的作用,其类结构如下图所示。

TextLine定义了一个缓存池sCached,并定义了obtain()和recycle()分别用于申请和释放TextLine实例。申请到TextLine之后,必须调用set()方法进行初始化,其实现中除了进行必要的成员变量的初始化外,还会根据是否含有ReplacementSpan进行一些字符替换的预处理。
TextLine作为文本测绘的具体执行者,负责处理当前文本行中的每一个字符。因此,与它交互的Span对象都是CharacterStyle,包括其MetricAffectingSpan和ReplacementSpan子类对象。
从抽象类CharacterStyle的定义中可以看出,其唯一的一个抽象方法updateDrawState()定义如下:
public abstract void updateDrawState(TextPaint tp);

updateDrawState()方法返回void,而且参数也只有一个TextPaint对象,因此,每一个Span所对应的字符的测绘需要严重依赖TextPaint对象。这一点,从TextLine的类结构图中也不难看出。TextLine中定义了三个TextPaint对象:mPaint、mWorkPaint和mActivePaint。它们三个均在文本的测绘中使用,其中

  • mPaint:在set()初始化TextLine传入的TextPaint对象,保留着当前TextLine正在处理的文本的初始TextPaint状态;

  • mWorkPaint:在文本测量和绘制过程中,负责收集所有当前生效的CharacterStyle对象的处理逻辑(作为参数调用updateDrawState()方法),初始状态由mPaint决定;

  • mActivePaint:最终真正参与测量和绘制的TextPaint对象,默认由mPaint初始化其内部状态,如果mWorkPaint有更新的话,则由mWorkPaint更新mActivePaint的内部状态。

以上三个TextPaint对象主要在TextLine#handleRun()中使用,mPaint将会在TextLine#recycle()回收时释放,mWorkPaint和mActivePaint则不会释放,只在使用时由mPaint重新初始化内部状态。

如前文所述,TextLine实现文本测绘严重依赖了TextPaint对象,而TextPaint具体主要在TextPaint#handleRun()中使用。另外,TextLine中关键的测量接口metrics()和measure(),以及绘制接口draw()最终都会依赖handleRun()方法来实现其相关的逻辑,因此,我们有必要介绍一下该方法的实现逻辑。
private float handleRun(int start, int measureLimit, int limit, boolean runIsRtl, Canvas c, float x,        int top, int y, int bottom, FontMetricsInt fmi,         boolean needWidth) {    // 1. 输入校验以及空行处理    ... // 2. 根据是否为Spanned对象,判断是否需要进行Span相关的逻辑处理 final boolean needsSpanMeasurement; if (mSpanned == null) { needsSpanMeasurement = false; } else { // 2.1 获取Spanned对象中的MetricAffectingSpan和CharacterStyle集合。 // SpanSet#init()中将会把Span对象、Span开始位置、 // Span结束位置以及相关的flag变量分别保存在spans、spanStarts、spanEnds、spanFlags数组中 ... needsSpanMeasurement = mMetricAffectingSpanSpanSet.numberOfSpans != 0 || mCharacterStyleSpanSet.numberOfSpans != 0; } // 3. 如果不需要进行Span相关的逻辑操作,直接更新mWorkPaint,然后调用handleText()进行测绘 if (!needsSpanMeasurement) { final TextPaint wp = mWorkPaint; wp.set(mPaint); ... return handleText(wp, ...); } // 4. 接下来进行Span对象的测绘。由于布局只需要考虑MetricAffectingSpan,    // 而渲染需要考虑字符样式,因此,这里在每一个文本串遍历过程中先遍历布局相关Span,    // 然后再遍历字符样式相关Span。 final float originalX = x; for (int i = start, inext; i < measureLimit; i = inext) { // 4.1 使用mPaint初始化work paint内部状态 final TextPaint wp = mWorkPaint; wp.set(mPaint);
...
ReplacementSpan replacement = null;        // 4.2 遍历并应用布局相关Span,同时更新replacement for (int j = 0; j < mMetricAffectingSpanSpanSet.numberOfSpans; j++) { ... }        // 4.3 遍历完所有布局相关的span后,如果replacement不为null,        // 则使用当前work paint执行相关的字符替换逻辑,        // 并跳过当前文本串的后续处理(即CharacterStyle相关的逻辑) if (replacement != null) { x += handleReplacement(replacement, wp, ...); continue; } // 以下为CharacterStyle相关的处理逻辑。        // 4.4 使用mPaint初始化active paint内部状态 final TextPaint activePaint = mActivePaint; activePaint.set(mPaint);        ...        // 4.5 遍历当前文本串,处理其中字符样式相关的span for (int j = i, jnext; j < mlimit; j = jnext) { ...            // 4.5.1 应用CharacterStyle相关span之前,使用mPaint重新初始化work paint wp.set(mPaint);            // 4.5.2 遍历每一个字符相关的span并应用到work paint ...            // 4.5.3 使用当前最新的work paint更新active paint内部状态, // 最终将会在handleText中应用到具体的测绘中 ...            // 4.5.4 使用当前active paint测绘文本            x += handleText(activePaint, ...); }        ...        // 4.6 计算并更新当前文本串的宽度 x += handleText(activePaint, ...); } // 5. 返回测量宽度。因为只有needWidth为true时x才是正确的结果,因此只有needWidth为true该返回值才有意义。 return x - originalX;}
handleRun()的关键过程在代码注释中已经介绍,这里不再赘述。其中,还有一个比较关键的方法TextLine#handleText()
在TextLine#handleText()中会调用TextLine#drawTextRun()方法渲染文本内容,最终会调用Canvas#drawTextRun()并将Paint传入到底层C++来渲染文本。
除此之外,TextLine#handleText()还会根据是否需要返回文本宽度,调用TextLine#getRunAdvance(),最终通过调用Paint相关的接口来获取文本最终的测量宽度值


5. 相互关系

根据上文的介绍,我们可以大致总结一下文本测绘的关键类之间的关系。

首先,TextPaint是文本测绘能力实现者,其通过与底层C++相关接口交互,将文本中的字体、大小、粗细、颜色、缩放等各种相关信息传递到C++,实现文本字符的测绘。而Span则可以通过影响TextPaint相关设置分别在字符级和段落级影响文本的测绘Spanned则为这其中的操作提供了一系列的抽象接口

Layout作为最上层的接口持有TextPaint作为通用的文本测绘信息,并持有Span相关对象作为更细粒度的信息控制文本测绘过程,最终完成整个文本的测绘。而TextLine作为每一行文本测绘任务的执行者,具体控制着Span与TextPaint之间交互的逻辑,并最终依赖TextPaint相关接口完成文本的测绘。


测绘流程


前文简要介绍了文本测绘的整体架构和其中关键类的实现,这里我们将以系统TextView中测量文本布局为例,简要介绍一下文本测绘的流程时序图。

注:下图由于平台大小限制,省略了很多过程和细节,需要高清大图的读者可以私信留言。

在TextView的onMeasure中,首先会根据MeasureSpec所指定的测量模式,和父节点传入的widthSize作为输入条件,计算出文本字符的预置宽度wantWidth。然后,以wantWidth为限制创建或者更新Layout对象mLayout,而mLayout是负责测量和绘制文本的接口。最后,调用setMeasureDimension()接口保存组件的measuredWidth和measuredHeight。
创建Layout对象的主要逻辑在makeSingleLayout()中。创建Layout时会先调用BoringLayout#isBoring()接口把文本内容当做单行普通文本来测量总体宽度,如果boring.width不大于wantWidth,则会创建一个BoringLayout作为Layout对象参与后续的文本组件的测绘流程。如果boring.width大于wantWidth,则表示无法在一行内显示全部文本内容,需要创建StaticLayout作为Layout对象参与后续文本组件的测绘流程。这个规则可以重点关注一下,因为后续介绍的文本截断问题与这里的规则息息相关。


本章小结


本章节相对系统而简要地介绍了Android系统文本测绘的原理,分别介绍了文本测绘的整体架构,以及其中涉及到的比较关键的对象如Spanned、Paint、Layout、TextLine等及其相互依赖关系,同时,重点分析了Span对象在TextLine#handleRun()中参与测绘过程的源码。最后,从TextView组件的角度,梳理了文本组件总体的测量流程,给出了以上各个关键对象在文本测量过程中所参与的具体环节。

由于文本测绘是一个非常复杂的过程,本章节所介绍的内容远远不足以覆盖全面,只希望读者可以在读完本章节后对文本测绘有一个大概的了解,在解决具体问题时能够有所帮助即可。


文本截断问题分析与解决


ReactNative文本截断问题,目前社区流传较为广泛的解决方案,均是从前端侧进行规避,包括添加空格、显示设置fontFamily、设置行高、设置breakStrategy为simple等等。社区的这些方案可以解决部分线下的文本截断case,但是,线上环境较为复杂,这些规避方案由于并没有从Native侧分析到文本截断的根本原因,因此并不能较为全面地解决线上用户侧的文本截断问题。

在分析ReactNative文本截断问题之前,我们先看一下ReactNative中和布局相关的Span,之后会结合两个实际案例进行分析。

ReactNative中Span全部实现了一个标记性接口ReactSpan,即上述类结构图中蓝色Span,其中ReactBackgroundColorSpan、CustomLetterSpacingSpan、ReactAbsoluteSizeSpan和CustomHeightSpan实现比较简单,只是将设置的相应参数应用到文本的测绘流程中。而CustomStyleSpan相对较为复杂,它根据业务设置的fontFamily、fontStyle、fontWeight等字体相关信息,创建相应的Typeface实例,然后应用到文本的测绘过程中,其中关键的主体逻辑如下:

// From CharacterStyle@Overridepublic void updateDrawState(TextPaint ds) { apply(ds, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager);}
// From MetricAffectingSpan@Overridepublic void updateMeasureState(TextPaint paint) { apply(paint, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager);}
private static void apply(Paint paint, int style, int weight, @Nullable String fontFeatureSettings, @Nullable String family, AssetManager assetManager) { Typeface typeface = ReactTypefaceUtils.applyStyles( paint.getTypeface(), style, weight, family, assetManager); paint.setFontFeatureSettings(fontFeatureSettings); paint.setTypeface(typeface); paint.setSubpixelText(true);}

在TextLine#handleRun()中应用CustomStyleSpan的时候,最终会调用到上述apply()方法。

apply()中首先根据当前TextPaint#getTypeface()返回值和业务设置的fontFamily等字体信息获取Typeface实例,然后将应用了这些字体信息之后得到的Typeface实例更新TextPaint中,从而影响文本的测量和绘制。

接下来,我们将以小米机型为例,分析两种文本截断问题的具体case,并在最后基于文本截断的共同特征给出一种线上文本截断问题的监控方案。

fontFamily为null导致的文本截断问题


由fontFamily为null(ReactNative中Text组件的缺省值)导致的文本截断问题在小米机型的MIUI12上复现概率较高,比如运行 https://snack.expo.dev/S6WUZyK3M 中的示例代码,文本字符“Hello React Native App”只显示“Hello React Native”。

上述代码中没有给Text组件设置fontFamily属性,则使用ReactNative框架中的默认值null,从如下流程图中可以看到,在onBeforeLayout阶段(红色虚线框部分),将会给Text组件添加CustomStyleSpan。

在接下来的YogaMeasure阶段(绿色虚线框部分)和onMeasure阶段(蓝色虚线框部分),CustomStyleSpan将会被分别应用到相应的TextPaint中,更新其mTypeface属性。
首先,在YogaMeasure阶段,由于ReactNative测量文本使用的TextPaint对象没有设置mTypeface属性,TextPaint#getTypeface()返回对象为null,在这种情况下,ReactTypefaceUtils#applyStyles()返回值也是null。
然而,在onMeasure阶段,即使没有设置任何字体信息,TextView#setTypefaceFromAttrs()也会给TextPaint设置系统默认字体,所以在这种情况下,ReactTypefaceUtils#applyStyles()返回值不是null。
通过断点调试可以发现,上述两个阶段最终测量的结果是不一样的,前者测量的宽度略微比后者少了几个像素。而前者测量的结果,会作为ReactNative中Yoga测量的宽度,传入TextView#onMeasure()中,进而计算出wantWidth。因为mBoring.width大于wantWidth,表示无法在一行内显示全部文本内容,最终使用StaticLayout进行换行布局,在高度不变的情况下,末尾的字符被绘制到新的一行中,用户侧看到的结果就是末尾的字符被截断了。
了解了这一类问题的根因之后,解决方案也是就比较明确了,只要保证YogaMeasure阶段所使用的TextPaint在fontFamily为null的时候也能使用系统默认字体即可。最终我们参考TextView#setTypefaceFromAttrs()设置字体的方式解决了这一类文本截断问题。

设置系统字体缩放导致的文本截断问题


这一类case也是一个线上用户反馈的问题。在MIUI13和MIUI14上设置字体缩放,然后运行 https://snack.expo.dev/fl5DSrLBJ 中的示例代码,文本字符“¥999”只显示“¥99”。

通过断点调试可以发现,上述文本渲染流程中:boring.width > wantWidth,TextView组件宽度不足以显示所有内容,因此,文本字符“999”必然会出现文本截断。
导致两次测量宽度不同的直接原因不在Java层,因为在Java层能够观察到的所有属性都是相同的。联系了MIUI字体相关的同学,帮忙一起分析了一下根因,目前得到的结论是:Misans字体会根据字号大小限制字体轴重的变化范围,加上MIUI13&14上framework中字体相关处理不完善,最终导致了ReactNative文本测量和排版两次结果不一致。
找到了根因,解决这一类问题也就明确了:在ReactNative业务设置字体大小时,不仅将字体大小应用在onMeasure阶段,同时也应用在onBeforeLayout阶段,显示地让两次测量使用相同的字号大小(默认使用ReactAbsoluteSizeSpan隐式设置),也就自然可以规避掉底层的字体测量bug。同时,MIUI同学也在新版本中修复了这一问题。
针对该问题,我们向ReactNative提了一个PR,目前已经合入主分支,PR链接参考:https://github.com/facebook/react-native/pull/39581

文本截断问题线上监控方案

结合上面两个文本截断的case,我们不难发现,导致文本截断的原因是有多种可能的,有些还可能是系统framework层的原因,应用层看不到任何问题。但是,不管是哪种原因导致的文本截断,它们都有一些共同的特征:
  • 针对单行文本,ReactNative测量得到一个Layout实例BoringLayout对象,并以其宽度作为布局宽度;
  • 而TextView的onMeasure阶段传入的wantWidth小于新测量得到的boring.width,导致TextView认为设置的宽度不足以在一行内展示所有文本内容,进而使用StaticLayout对象进行多行渲染;
  • 高度不变的情况下,将单行文本渲染成两行后,末尾部分字符无法显示。

1. 监控特征分析

  • TextView#mBoring

TextView中BoringLayout.Metrics类型私有变量,父类是Paint.FontMetricsInt类型。它具有如下特点:
    • 文本截断时,TextView#onMeasure()之后,mBoring不为null。
    • 文本截断时,mText中不包含特殊字符 \t 和 \n 。
    • 文本截断时,其width字段大于wantWidth。

该成员没有暴露getter接口且使用了“非公开API限制”,子类中访问需要使用元反射技术。

  • TextView#mLayout

TextView中Layout类型私有变量。Layout是一个抽象类,是BoringLayout、StaticLayout和DynamicLayout的父类。TextView#mLayout具有如下特点:

    • 文本截断时,该变量实例为StaticLayout。
    • 文本截断时,mLayout#getLineCount()大于1。
    • 文本截断时,ReactNative的Yoga测量使用的Layout实例类型为BoringLayout。


2. 监控方案实现

监控方案的实现比较简单,方案具体如下:

其中,监控的条件主要包含以下两点(其中Yoga.XXX表示在ReactNative侧Yoga测量过程中产生的对象):
  • TextView.mBoring.width > Yoga.mBoring.width
  • TextView.mLayout.getLineCount() > Yoga.mLayout.getLineCount()

即,TextView中测量的文本字符宽度大于Yoga中测量的宽度,文本行数大于Yoga中测量得到的文本行数。


总结


本文主要针对ReactNative中由来已久的文本截断问题,进行了深入分析与解决。同时,对Android系统文本测绘的整体架构和流程进行了较为全面而简要的介绍。然而,文本测绘中的很多细节都还没有提到,当我们遇到文本测绘相关的具体问题时,可以参考本文介绍的整体架构和流程,快速准确地定位到相关问题的原因。

想要熟练掌握文本测绘的相关知识,则需要我们在实践中不断总结与复盘,从而得到进一步的提高。


参考资料


    1. Spans, a Powerful Concept. - Flavien Laurent

    2. Android Span 原理解析-六虎

    3. Bidirectional text - Wikipedia

    4. Android之TextView文字绘制流程 - bvin - 博客园

    5. Android字符串进阶之三:字体属性及测量(FontMetrics)_51CTO博客_android 字符串比较

    6. Android字体系列 (一):Android字体基础 - 掘金

    7. Android字体系列 (二):Typeface完全解析 - 掘金

    8. TextView绘制流程二、BoringLayout深入理解 - 掘金

    9. Draw Text in Deep - 掘金

    10. React Native小米手机UI适配问题解决 - 简书

    11. 可变字体带来的文字设计变革 | Monotype.

    12. 字由-Typeface和Font 傻傻分不清?| 字说字话

欢迎加入

快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。


目前团队正在招贤纳士,欢迎对大前端技术感兴趣的同学投递简历~


我们期待你的加入!请发简历到:

chenguang03@kuaishou.com

lizhiqiang05@kuaishou.com


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存